iT邦幫忙

2025 iThome 鐵人賽

DAY 2
0
生成式 AI

AI LeetCode 助教:30 天打造智慧刷題系統系列 第 2

Day 2:資料模型擴充 + 篩選查詢 + 基礎 CRUD

  • 分享至 

  • xImage
  •  

今日目標

將後端 FastAPI API 和前端Streamlit介面成功連接起來,並能在前端查詢後端資料庫中的題目列表,作為 Mock 題庫的測試環節,另外,也要確保API架構與前端呼叫流程正確對應,避免資料傳遞錯誤。

  • 啟動並測試後端 API(/health 與 /problems)。
  • 在前端(Streamlit)實作 API 呼叫邏輯。
  • 解決跨端連線與環境設定問題。
  • 驗證題目查詢功能(難度、主題、關鍵字)。

實作

1. 後端:資料模型擴充

  • User:代表學習者,先存 email/name/level;level 之後會由程度測驗與歷史表現自動調整。
  • Submission:紀錄某使用者對某題的一次提交(語言、原始碼、判定結果、時間)。
# backend/models.py
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Text
from sqlalchemy.orm import relationship
from datetime import datetime
from .database import Base

class Problem(Base):
    __tablename__ = "problems"
    id = Column(Integer, primary_key=True, index=True)
    slug = Column(String, unique=True, index=True)
    title = Column(String, index=True)
    difficulty = Column(String)   # Easy/Medium/Hard
    topic = Column(String)  # Array/DP/BFS...
    is_active = Column(Boolean, default=True)

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True, index=True)
    email = Column(String, unique=True, index=True)
    name = Column(String)
    level = Column(String, default="beginner") 
    is_active = Column(Boolean, default=True)

    submissions = relationship("Submission", back_populates="user")

class Submission(Base):
    __tablename__ = "submissions"
    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id"))
    problem_id = Column(Integer, ForeignKey("problems.id"))
    language = Column(String, default="python")
    code = Column(Text)  #使用者貼過來的程式碼
    verdict = Column(String, default="pending")
    created_at = Column(DateTime, default=datetime.utcnow)

    user = relationship("User", back_populates="submissions")

2. 後端:Pydantic DTO

把 API 的輸入/輸出格式獨立集中管理,避免把資料庫實體直接暴露出去。
說明:

  • ProblemCreate:新增題目時允許的欄位
  • ProblemOut:回傳題目列表的欄位
  • UserCreate/UserOut、SubmissionCreate/SubmissionOut:同理
  • ProblemQuery:之後若想把查詢參數整理成一個物件,可用 Depends接。
# backend/dto.py
from pydantic import BaseModel, ConfigDict
from typing import Optional, Literal

# Problems 
class ProblemOut(BaseModel):
    id: int
    slug: str
    title: str
    difficulty: str
    topic: str
    model_config = ConfigDict(from_attributes=True)

class ProblemCreate(BaseModel):
    slug: str
    title: str
    difficulty: Literal["Easy", "Medium", "Hard"]
    topic: str

# 查詢參數 DTO(給 FastAPI Depends 使用)
class ProblemQuery(BaseModel):
    difficulty: Optional[str] = None
    topic: Optional[str] = None
    q: Optional[str] = None

# Users
class UserOut(BaseModel):
    id: int
    email: str
    name: str
    level: str
    model_config = ConfigDict(from_attributes=True)

class UserCreate(BaseModel):
    email: str
    name: str

# Submissions
class SubmissionCreate(BaseModel):
    user_id: int
    problem_id: int
    language: str = "python"
    code: str

class SubmissionOut(BaseModel):
    id: int
    user_id: int
    problem_id: int
    language: str
    verdict: str
    model_config = ConfigDict(from_attributes=True)

3. 後端:Problems 路由升級

把 Day 1 的 GET/problems 擴充為可篩選版本,並加入 POST/problems 新增題目。

# backend/routers/problems.py
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from ..database import SessionLocal
from .. import models
from ..dto import ProblemOut, ProblemCreate

router = APIRouter(prefix="/problems", tags=["problems"])

def get_db():
    db = SessionLocal()
    try: yield db
    finally: db.close()

@router.get("", response_model=List[ProblemOut])
def list_problems(
    difficulty: Optional[str] = Query(None),
    topic: Optional[str] = Query(None),
    q: Optional[str] = Query(None),
    db: Session = Depends(get_db),
):
    qs = db.query(models.Problem).filter(models.Problem.is_active == True)
    if difficulty:
        qs = qs.filter(models.Problem.difficulty == difficulty)
    if topic:
        qs = qs.filter(models.Problem.topic == topic)
    if q:
        like = f"%{q}%"
        qs = qs.filter(models.Problem.title.ilike(like))
    return qs.order_by(models.Problem.id.asc()).all()

@router.post("", response_model=ProblemOut, status_code=status.HTTP_201_CREATED)
def create_problem(payload: ProblemCreate, db: Session = Depends(get_db)):
    # slug 唯一性檢查
    exists = db.query(models.Problem).filter(models.Problem.slug == payload.slug).first()
    if exists:
        raise HTTPException(409, "slug already exists")
    obj = models.Problem(**payload.model_dump(), is_active=True)
    db.add(obj)
    db.commit()
    db.refresh(obj)
    return obj

4. 後端:Users 與 Submissions 路由

提供可用的建立使用者與紀錄提交API。
說明:

  • POST /users:建立學習者(用email)
  • GET /users:列出所有有效使用者
  • POST /submissions:寫入一次提交(包含語言與原始碼)
  • GET /submissions:查看最新提交列表。
# backend/routers/users.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from ..database import SessionLocal
from .. import models
from ..dto import UserCreate, UserOut

router = APIRouter(prefix="/users", tags=["users"])

def get_db():
    db = SessionLocal()
    try: yield db
    finally: db.close()

@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(payload: UserCreate, db: Session = Depends(get_db)):
    exists = db.query(models.User).filter(models.User.email == payload.email).first()
    if exists:
        raise HTTPException(409, "email already exists")
    obj = models.User(email=payload.email, name=payload.name)
    db.add(obj); db.commit(); db.refresh(obj)
    return obj

@router.get("", response_model=List[UserOut])
def list_users(db: Session = Depends(get_db)):
    return db.query(models.User).filter(models.User.is_active == True).all()
# backend/routers/submissions.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from typing import List
from ..database import SessionLocal
from .. import models
from ..dto import SubmissionCreate, SubmissionOut

router = APIRouter(prefix="/submissions", tags=["submissions"])

def get_db():
    db = SessionLocal()
    try: yield db
    finally: db.close()

@router.post("", response_model=SubmissionOut, status_code=status.HTTP_201_CREATED)
def create_submission(payload: SubmissionCreate, db: Session = Depends(get_db)):
    obj = models.Submission(**payload.model_dump())
    db.add(obj); db.commit(); db.refresh(obj)
    return obj

@router.get("", response_model=List[SubmissionOut])
def list_submissions(db: Session = Depends(get_db)):
    return db.query(models.Submission).order_by(models.Submission.id.desc()).all()

5. 後端:app.py加入CORS與新路由(延續Day 1)

CORS:前端要呼叫後端,若沒有CORS就會被瀏覽器阻擋。

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from .database import Base, engine
from .routers import problems
from .routers import users
from .routers import submissions

app = FastAPI(title="AI LeetCode Tutor API")

# 建表
Base.metadata.create_all(bind=engine)

# CORS(開發階段先全開)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.get("/", include_in_schema=False)
def root():
    return RedirectResponse(url="/docs")

@app.get("/health")
def health():
    return {"status": "ok"}

# 路由
app.include_router(problems.router)
app.include_router(users.router)
app.include_router(submissions.router)

做完以上步驟,就可以開啟後端:

python -m uvicorn backend.app:app --reload

6. 前端:加入篩選 UI(延續Day 1)

新增功能:

  • 介面提供三種篩選:難度、主題、關鍵字。
  • 側邊欄顯示後端狀態,用於Debug,之後會移除。
  • 透過 requests.get呼叫後端,顯示回傳清單。
# frontend/app.py
import streamlit as st
import requests
import os

API_BASE = os.getenv("API_BASE", "http://127.0.0.1:8000")

st.set_page_config(page_title="AI LeetCode 助教", layout="wide")
st.title("AI LeetCode 助教(Day 2)")

with st.sidebar:
    st.header("後端狀態")
    try:
        ok = requests.get(f"{API_BASE}/health", timeout=3).json()
        st.success(f"API: {ok}")
    except Exception as e:
        st.error(f"API 連線失敗:{e}")

st.subheader("題目列表(可篩選)")
col1, col2, col3 = st.columns(3)
with col1:
    difficulty = st.selectbox("難度", ["", "Easy", "Medium", "Hard"])
with col2:
    topic = st.text_input("主題(Array, DP...)", "")
with col3:
    q = st.text_input("關鍵字(標題搜尋)", "")

params = {}
if difficulty: params["difficulty"] = difficulty
if topic: params["topic"] = topic
if q: params["q"] = q

try:
    res = requests.get(f"{API_BASE}/problems", params=params, timeout=8)
    data = res.json()
    st.caption(f"共 {len(data)} 題")
    for p in data:
        st.write(f"- [{p['difficulty']}] {p['title']}  / topic: {p['topic']}  / slug: `{p['slug']}`")
except Exception as e:
    st.error(f"讀取題目失敗:{e}")

接者就可以啟動前端:

streamlit run app.py

今日成果

https://ithelp.ithome.com.tw/upload/images/20250816/20146177sbH1hxW3oc.png
https://ithelp.ithome.com.tw/upload/images/20250816/2014617718LWxz0kY3.png


上一篇
Day 1 — 專案啟動與雛形建立:AI LeetCode 助教
下一篇
Day 3:新增程度測驗流程
系列文
AI LeetCode 助教:30 天打造智慧刷題系統4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言